본문으로 건너뛰기

Block End-to-End

Q1. Device Layout

Raw block 디바이스는 크게 두 영역으로 나뉩니다. 각 영역의 역할과, data_base_offset이 가리키는 위치를 설명해 주세요. 또한 기본값 기준으로 숫자가 얼마인지도 말씀해 주세요.

A1.

디바이스는 두 영역으로 나뉩니다.

① 메타데이터 영역 — offset 0 ~ meta_total_bytes

인덱스(key→offset 맵), free slot 목록, next_slot을 JSON 체크포인트로 저장합니다. 내부적으로 _meta_copy_count = 2 (core.py L231)로 2벌 미러링됩니다:

# core.py L231-234
self._meta_copy_count: int = 2
self._meta_container_bytes: int = (
(self.meta_total_bytes // self._meta_copy_count) // self.block_align
) * self.block_align

각 컨테이너는 header 1 block + payload 구조입니다.

② 데이터 영역meta_total_bytes ~ end

고정 크기 슬롯의 배열입니다. _data_base_offset이 이 경계를 가리킵니다:

# core.py L988-990
self._data_base_offset = self.meta_total_bytes
data_bytes = self._effective_capacity_bytes - self._data_base_offset
self._max_slots = data_bytes // self.slot_bytes

기본값 (rust_raw_block_backend.py L217, L233-234):

파라미터기본값
block_align4,096 B
header_bytes4,096 B
meta_total_bytes128 MB

data_base_offset = 128 MB


Q2. Slot 구조와 크기 계산

slot_bytes는 어떻게 계산되나요? header_bytes, full_chunk_bytes, block_align이 각각 어떤 역할을 하는지, 그리고 왜 round_up이 필요한지 설명해 주세요.

A2.

슬롯 하나의 구조:

[ header_bytes | payload (full_chunk_bytes) | padding ]
← header_bytes →←────── slot_bytes - header_bytes ─────────→

각 파라미터 역할:

  • header_bytes: 슬롯 앞에 붙는 고정 헤더 크기. 내부 포맷(_encode_header, core.py L940-950): LMCBLK01(8B) + slot_identity(8B) + payload_len(8B) = 24B 최소. 기본값은 4096B — O_DIRECT 정렬을 위해 크게 잡습니다.
  • full_chunk_bytes: L1 CPU 백엔드에서 측정한 실제 KV 텐서 1청크 크기입니다.
  • block_align: O_DIRECT가 요구하는 정렬 단위 (보통 4096B).

slot_bytes 기본값 계산 (rust_raw_block_backend.py L259):

default_slot_bytes = round_up(header_bytes + full_chunk_bytes, block_align)

왜 round_up이 필요한가: O_DIRECT는 커널 페이지 캐시를 바이패스하는 대신 파일 offset과 I/O 크기가 모두 block_align의 배수여야 합니다. slot_bytes가 정렬되지 않으면 _slot_to_offset(slot+1) = _data_base_offset + (slot+1) * slot_bytes가 정렬되지 않아 다음 슬롯의 I/O가 실패합니다. Validation이 이를 강제합니다:

# core.py L208-213
if self.header_bytes % self.block_align != 0:
raise ValueError(...)
if self.slot_bytes % self.block_align != 0:
raise ValueError(...)

Q3. Write Path 중복 방지

batched_submit_put_task에서 동일 키에 대한 중복 쓰기를 막기 위해 두 가지 체크를 합니다. 두 체크의 차이점은 무엇이고, 왜 하나만으로는 부족한가요?

A3.

put_many L467-472의 두 체크:

with self._lock:
if key.encoded in self._index: # 체크 1
results[i] = True
continue
if key.encoded in self._inflight: # 체크 2
continue
# 여기서 슬롯 할당 + _inflight 등록

# lock 해제 후
success = self._write_one(key, obj, offset) # L494 — lock 없이 I/O 진행

두 체크의 차이:

_index 체크_inflight 체크
의미이미 I/O 완료 + 인덱싱됨현재 다른 스레드가 I/O 중
결과True 반환 (성공)False 반환 (skip)

왜 하나만으로는 부족한가:

lock 안에서 _inflight에 등록하고 lock을 해제한 뒤 _write_one이 실행됩니다(L492→L494). 이 사이에 다른 스레드가 같은 키를 시도하면:

  • _index만 체크하면: I/O 완료 전이라 _index에 없음 → 슬롯을 중복 할당하고 같은 키로 두 번 쓰는 race 발생
  • _inflight만 체크하면: 이미 완료된 키(_index 있음)에 대해 불필요하게 새 슬롯 할당 가능

두 체크가 합쳐서 "완료됐거나 진행 중이면 모두 skip"을 구현합니다.


Q4. Read Path — "Prefix" 의미

batched_get_blocking은 요청한 모든 키를 개별적으로 반환하지 않고, leading hit prefix만 반환합니다. 왜 이런 설계인지, 그리고 read path에서 lock=True → unlock_many 패턴이 존재하는 이유는 무엇인가요?

A4.

get_metadata_prefix L363-374:

for encoded_key in encoded_keys:
entry = self._index.get(encoded_key)
if entry is None:
break # 첫 번째 miss에서 즉시 중단
metas.append(entry.meta)
if lock:
self._lock_refcnt[encoded_key] += 1

왜 prefix만 반환하는가: LLM KV cache는 토큰 시퀀스에 1:1 대응합니다. [t0, t1, t2, t3]를 요청할 때 t1이 없으면 t2·t3는 t1의 attention 계산 없이는 쓸 수 없습니다. 중간이 빠진 히트는 의미가 없으므로, 앞에서부터 연속된 히트(leading prefix)만 반환합니다.

lock=True → unlock_many 패턴이 존재하는 이유: 로드 중에 eviction이 해당 슬롯을 재사용하면 읽어들이는 데이터가 오염됩니다. lock을 잡으면 delete_manyforce=False 경로(L674-677)에서 잠긴 키를 삭제하지 못합니다:

# core.py L674-677
locked = self._lock_refcnt.get(encoded_key, 0) > 0
if entry is not None and locked and not force:
deleted.append(False)
continue

로드가 끝난 뒤 unlock_many(L638-651)로 refcount를 감소시켜 eviction이 다시 가능해집니다.


Q5. Key Namespace와 슬롯 Identity

"legacy" 와 "object" 두 key namespace가 있습니다. 각각 언제 사용되고, slot_identity를 계산하는 방법이 왜 다른가요? (hint: 두 방식 모두 uint64입니다)

A5.

legacy namespaceencode_legacy_key (key_codec.py L118-130):

slot_identity = int(key.chunk_hash) & _UINT64_MASK

비MP(단일 프로세스) 경로의 CacheEngineKey / LayerCacheEngineKey에서 사용됩니다. CacheEngineKey에 이미 chunk_hash(content hash) 필드가 있으므로 그것을 직접 uint64로 잘라 씁니다. 추가 해싱이 불필요합니다.

object namespace_object_slot_identity (key_codec.py L158-168):

digest = hashlib.blake2b(encoded.encode("utf-8"), digest_size=8).digest()
return int.from_bytes(digest, "little", signed=False)

MP 경로의 ObjectKey에서 사용됩니다. encoded 형태는 model_name@kv_rank@chunk_hash[@cache_salt] 조합 문자열입니다. chunk_hash만 떼어 쓰면 model_name/kv_rank/cache_salt가 다른 키끼리 identity가 충돌할 수 있으므로, 전체 encoded string을 blake2b로 해싱해 8바이트 uint64로 만듭니다.

두 방식 모두 uint64인 이유: 슬롯 헤더에 LMCBLK01(8B) + slot_identity(8B) + payload_len(8B) = 24B 고정 포맷으로 저장되기 때문입니다(_encode_header, L940-950). 복구 시 헤더를 읽어 expected identity와 비교해 슬롯 유효성을 검증합니다(_validate_loaded_entries, L1419-1430).


보너스: Metadata Checkpoint 트리거 조건

체크포인트는 언제 저장되나요? meta_checkpoint_interval_sec과 meta_idle_quiet_ms가 각각 어떤 조건을 제어하는지 설명해 주세요.

A (보너스).

_checkpoint_once L1200-1211:

dirty = self._meta_dirty_total > self._meta_persisted
idle_ok = self._inflight_io_count == 0 and (
time.monotonic() - self._last_io_ts
) >= (self.meta_idle_quiet_ms / 1000.0)

if not dirty:
return False
if not force and not idle_ok:
return False
  • meta_checkpoint_interval_sec: _checkpoint_loop(L1025)에서 _meta_stop_evt.wait(interval) — 이 주기마다 깨어나 _checkpoint_once(force=False)를 호출합니다. "언제 시도할지" 의 주기입니다.

  • meta_idle_quiet_ms: 마지막 I/O(_last_io_ts) 이후로 _inflight_io_count == 0인 상태가 이 시간만큼 지속되어야 checkpoint가 허용됩니다. "얼마나 조용해야 쓸지" 의 조건입니다. I/O 중에 인덱스를 디스크에 쓰면 실제 슬롯 내용과 불일치할 수 있어 crash 후 복구 오류가 생기므로, 반드시 idle을 기다립니다.

체크포인트는 "주기가 됐고(interval), 더티가 있고(dirty), I/O가 없는 조용한 시점(idle_quiet)" 세 조건이 동시에 만족될 때만 기록됩니다.